[id].vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. <template>
  2. <div class="admin--page-content">
  3. <div v-if="isLoading" class="admin--table-loading" style="padding:60px;text-align:center;">
  4. 데이터를 불러오는 중...
  5. </div>
  6. <div v-else-if="!challenge" class="admin--table-empty" style="padding:60px;text-align:center;">
  7. 해당 챌린지를 찾을 수 없습니다.
  8. <div class="mt--16">
  9. <button class="admin--btn" @click="goToList">← 목록으로</button>
  10. </div>
  11. </div>
  12. <template v-else>
  13. <!-- ============================
  14. 메인 탭
  15. ============================ -->
  16. <div class="admin--main-tabs">
  17. <button
  18. type="button"
  19. :class="{ 'is-active': activeMainTab === 'challenge' }"
  20. @click="activeMainTab = 'challenge'"
  21. >
  22. 챌린지관리
  23. </button>
  24. <button
  25. type="button"
  26. :class="{ 'is-active': activeMainTab === 'applicants' }"
  27. @click="activeMainTab = 'applicants'"
  28. >
  29. 신청자관리
  30. </button>
  31. <button
  32. type="button"
  33. :class="{ 'is-active': activeMainTab === 'participants' }"
  34. @click="activeMainTab = 'participants'"
  35. >
  36. 참가자관리
  37. </button>
  38. </div>
  39. <!-- ============================
  40. 신청자관리 (준비중)
  41. ============================ -->
  42. <div v-if="activeMainTab === 'applicants'" class="admin--placeholder">
  43. <p>📝 신청자관리는 준비중입니다.</p>
  44. </div>
  45. <!-- ============================
  46. 참가자관리 (준비중)
  47. ============================ -->
  48. <div v-else-if="activeMainTab === 'participants'" class="admin--placeholder">
  49. <p>👥 참가자관리는 준비중입니다.</p>
  50. </div>
  51. <!-- ============================
  52. 챌린지관리 (기본 활성)
  53. ============================ -->
  54. <div v-show="activeMainTab === 'challenge'" class="admin--form">
  55. <table class="admin--form--table">
  56. <colgroup>
  57. <col style="width: 140px;">
  58. <col>
  59. </colgroup>
  60. <tbody>
  61. <tr>
  62. <th><div>챌린지명</div></th>
  63. <td>{{ challenge.name }}</td>
  64. </tr>
  65. <tr>
  66. <th><div>참가비</div></th>
  67. <td>{{ formatFee(challenge.fee) }}</td>
  68. </tr>
  69. <tr>
  70. <th><div>기간</div></th>
  71. <td>{{ formatDate(challenge.start_date) }} ~ {{ formatDate(challenge.end_date) }}</td>
  72. </tr>
  73. <tr>
  74. <th><div>최대 참가자</div></th>
  75. <td>{{ challenge.max_participants }}명</td>
  76. </tr>
  77. <tr>
  78. <th><div>총 라운드</div></th>
  79. <td>{{ challenge.total_rounds }}R</td>
  80. </tr>
  81. <tr>
  82. <th><div>타이틀 이미지</div></th>
  83. <td>
  84. <div v-if="challenge.file_path" class="onboard--photo-grid">
  85. <div class="onboard--photo-item">
  86. <img :src="getImageUrl(challenge.file_path)" :alt="challenge.file_name || challenge.name" />
  87. </div>
  88. </div>
  89. <template v-else>-</template>
  90. </td>
  91. </tr>
  92. <tr>
  93. <th><div>현재 상태</div></th>
  94. <td>
  95. <span :class="['admin--badge', statusBadgeClass(challenge.derived_status)]">
  96. {{ statusLabel(challenge.derived_status) }}
  97. </span>
  98. <span v-if="challenge.closed_at" class="txt--muted ml--16">
  99. ({{ formatDateTime(challenge.closed_at) }} 마감)
  100. </span>
  101. </td>
  102. </tr>
  103. <tr>
  104. <th><div>노출 여부</div></th>
  105. <td>
  106. <span :class="['admin--badge', challenge.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
  107. {{ challenge.status_YN === 'Y' ? '사용중' : '미사용' }}
  108. </span>
  109. </td>
  110. </tr>
  111. <tr>
  112. <th><div>등록일</div></th>
  113. <td>{{ formatDateTime(challenge.created_at) }}</td>
  114. </tr>
  115. <tr v-if="challenge.description">
  116. <th><div>상세내용</div></th>
  117. <td>
  118. <div class="admin--detail--content" v-html="challenge.description"></div>
  119. </td>
  120. </tr>
  121. </tbody>
  122. </table>
  123. <!-- ============================
  124. 라운드 정보 (탭)
  125. ============================ -->
  126. <h3 class="admin--table--middle--title mb--8">라운드 정보</h3>
  127. <div class="admin--round--tabs">
  128. <button
  129. v-for="(round, rIdx) in challenge.rounds"
  130. :key="round.id"
  131. type="button"
  132. class="admin--round--tab"
  133. :class="{ 'is-active': activeRoundIdx === rIdx }"
  134. @click="activeRoundIdx = rIdx"
  135. >
  136. 라운드 {{ round.round_no }}
  137. <span v-if="round.closed_at" class="admin--round--tab__badge">종료</span>
  138. </button>
  139. </div>
  140. <div
  141. v-for="(round, rIdx) in challenge.rounds"
  142. v-show="activeRoundIdx === rIdx"
  143. :key="round.id"
  144. class="admin--round--box--wrap"
  145. >
  146. <div class="admin--round--title">
  147. 라운드 {{ round.round_no }}
  148. <span>
  149. {{ round.place_mode === 'all' ? '전체 장소에 동일 적용' : '장소별 개별 설정' }}
  150. ㆍ 진출자 {{ round.qualified }}{{ rIdx === 0 ? '명' : '%' }}
  151. </span>
  152. <span v-if="round.closed_at" class="closed--txt">
  153. 마감 ({{ formatDateTime(round.closed_at) }})
  154. </span>
  155. <button
  156. v-else-if="currentRoundIdx === rIdx && !challenge.closed_at"
  157. type="button"
  158. class="admin--btn-small admin--btn-red"
  159. @click="confirmCloseRound(round)"
  160. >
  161. 라운드 마감
  162. </button>
  163. </div>
  164. <div class="admin--round--box">
  165. <!-- all 모드 -->
  166. <div v-if="round.place_mode === 'all'" class="mt--16">
  167. <p class="mb--8">배정 아이템 ({{ round.items.length }})</p>
  168. <ul v-if="round.items.length > 0" class="admin--item-modal__grid">
  169. <li v-for="it in round.items" :key="it.id" class="admin--item-modal__card">
  170. <div style="padding:12px 12px 14px;">
  171. <div class="admin--item-modal__thumb">
  172. <img
  173. v-if="it.file_path"
  174. :src="getImageUrl(it.file_path)"
  175. :alt="it.name"
  176. />
  177. <div v-else class="admin--item-modal__no-img">🎁</div>
  178. </div>
  179. <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
  180. <div class="admin--item-modal__meta">
  181. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  182. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  183. </div>
  184. </div>
  185. </li>
  186. </ul>
  187. <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
  188. </div>
  189. <!-- specific 모드 -->
  190. <template v-else>
  191. <!-- <p class="mb--8">장소 묶음 ({{ round.places.length }})</p> -->
  192. <div
  193. v-for="(place, pIdx) in round.places"
  194. :key="place.group_no"
  195. class="round--place--wrap"
  196. :class="{ 'mt--16': pIdx > 0 }"
  197. >
  198. <div class="admin--round--title">
  199. 장소 {{ pIdx + 1 }}
  200. <span class="txt--muted">{{ placeCountText(place.onboards) }}</span>
  201. </div>
  202. <div class="place--select--wrap">
  203. <p class="mt--8 mb--4">장소 목록</p>
  204. <div class="item--selected--wrap">
  205. <div
  206. v-for="o in place.onboards"
  207. :key="o.id"
  208. :class="[o.place_type === 'onboard' ? 'item--selected onboard' : 'item--selected']"
  209. >
  210. {{ o.place_type === 'onboard' ? '🚤' : '🎣' }} {{ o.place_name || '(삭제됨)' }}
  211. </div>
  212. </div>
  213. <p class="mt--16 mb--4">배정 아이템 ({{ place.items.length }})</p>
  214. <ul v-if="place.items.length > 0" class="admin--item-modal__grid">
  215. <li v-for="it in place.items" :key="it.id" class="admin--item-modal__card">
  216. <div style="padding:12px 12px 14px;">
  217. <div class="admin--item-modal__thumb">
  218. <img
  219. v-if="it.file_path"
  220. :src="getImageUrl(it.file_path)"
  221. :alt="it.name"
  222. />
  223. <div v-else class="admin--item-modal__no-img">🎁</div>
  224. </div>
  225. <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
  226. <div class="admin--item-modal__meta">
  227. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  228. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  229. </div>
  230. </div>
  231. </li>
  232. </ul>
  233. <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
  234. </div>
  235. </div>
  236. </template>
  237. </div>
  238. </div>
  239. <!-- ============================
  240. 액션 버튼
  241. ============================ -->
  242. <div class="admin--form-actions">
  243. <button type="button" class="admin--btn" @click="goToList">
  244. ← 목록으로
  245. </button>
  246. <button type="button" class="admin--btn admin--btn-blue ml--auto" @click="goToEdit">
  247. 수정
  248. </button>
  249. <button type="button" class="admin--btn admin--btn-red" @click="confirmDelete">
  250. 삭제
  251. </button>
  252. </div>
  253. <!-- 메시지 -->
  254. <div v-if="successMessage" class="admin--alert admin--alert-success">{{ successMessage }}</div>
  255. <div v-if="errorMessage" class="admin--alert admin--alert-error">{{ errorMessage }}</div>
  256. </div>
  257. </template>
  258. <!-- 삭제 확인 모달 -->
  259. <AdminAlertModal
  260. v-if="showDeleteModal"
  261. title="챌린지 삭제"
  262. :message="`'${challenge?.name}' 챌린지를 삭제하시겠습니까?\n삭제된 챌린지는 복원할 수 있습니다.`"
  263. type="confirm"
  264. @confirm="handleDelete"
  265. @cancel="showDeleteModal = false"
  266. @close="showDeleteModal = false"
  267. />
  268. <!-- 라운드 마감 확인 모달 -->
  269. <AdminAlertModal
  270. v-if="showCloseRoundModal"
  271. title="라운드 마감"
  272. :message="`라운드 ${closingRound?.round_no}을(를) 마감하시겠습니까?\n마지막 라운드일 경우 챌린지도 함께 자동 종료됩니다.`"
  273. type="confirm"
  274. @confirm="handleCloseRound"
  275. @cancel="() => { showCloseRoundModal = false; closingRound = null }"
  276. @close="() => { showCloseRoundModal = false; closingRound = null }"
  277. />
  278. </div>
  279. </template>
  280. <script setup>
  281. import { ref, computed, onMounted } from "vue";
  282. import { useRoute, useRouter } from "vue-router";
  283. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  284. definePageMeta({
  285. layout: "admin",
  286. middleware: ["auth"],
  287. });
  288. const route = useRoute();
  289. const router = useRouter();
  290. const { get, post, del } = useApi();
  291. const { getImageUrl } = useImage();
  292. const challengeId = Number(route.params.id);
  293. const isLoading = ref(false);
  294. const challenge = ref(null);
  295. const successMessage = ref("");
  296. const errorMessage = ref("");
  297. const showDeleteModal = ref(false);
  298. const activeRoundIdx = ref(0); // 현재 선택된 라운드 탭
  299. const activeMainTab = ref("challenge"); // 'challenge' | 'applicants' | 'participants'
  300. const showCloseRoundModal = ref(false);
  301. const closingRound = ref(null);
  302. // 현재 라운드 인덱스 (closed_at NULL인 가장 작은 round_no)
  303. const currentRoundIdx = computed(() => {
  304. if (!challenge.value?.rounds?.length) return -1;
  305. const idx = challenge.value.rounds.findIndex((r) => !r.closed_at);
  306. return idx; // -1 이면 모든 라운드 마감 상태
  307. });
  308. const statusLabel = (s) =>
  309. s === "hidden" ? "비노출"
  310. : s === "recruiting" ? "모집중"
  311. : s === "running" ? "진행중"
  312. : s === "ended" ? "종료"
  313. : "-";
  314. const statusBadgeClass = (s) =>
  315. s === "hidden" ? "admin--badge-hidden"
  316. : s === "recruiting" ? "admin--badge-recruiting"
  317. : s === "running" ? "admin--badge-running"
  318. : s === "ended" ? "admin--badge-ended"
  319. : "";
  320. const formatDate = (s) => {
  321. if (!s) return "-";
  322. const d = new Date(s.replace(" ", "T"));
  323. if (isNaN(d.getTime())) return s;
  324. return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
  325. };
  326. const formatDateTime = (s) => {
  327. if (!s) return "-";
  328. const d = new Date(s.replace(" ", "T"));
  329. if (isNaN(d.getTime())) return s;
  330. return d.toLocaleString("ko-KR", {
  331. year: "numeric", month: "2-digit", day: "2-digit",
  332. hour: "2-digit", minute: "2-digit",
  333. });
  334. };
  335. // 장소 묶음의 type별 카운트 텍스트 ("선상 3개ㆍ낚시터 2개" / 한쪽만 있으면 그쪽만)
  336. const placeCountText = (onboards) => {
  337. const list = onboards || [];
  338. const onb = list.filter((o) => o.place_type === "onboard").length;
  339. const fis = list.filter((o) => o.place_type === "fishing").length;
  340. const parts = [];
  341. if (onb > 0) parts.push(`선상 ${onb}개`);
  342. if (fis > 0) parts.push(`낚시터 ${fis}개`);
  343. return parts.join("ㆍ") || "장소 없음";
  344. };
  345. const formatFee = (fee) => {
  346. if (fee === null || fee === undefined || fee === "") return "-";
  347. const num = Number(String(fee).replace(/[^\d]/g, ""));
  348. if (isNaN(num) || num === 0) return fee + "";
  349. return num.toLocaleString() + "원";
  350. };
  351. const loadChallenge = async () => {
  352. isLoading.value = true;
  353. try {
  354. const { data, error } = await get(`/challenge/${challengeId}`);
  355. if (error || !data?.success) {
  356. challenge.value = null;
  357. return;
  358. }
  359. challenge.value = data.data;
  360. // 페이지 진입 시 활성 탭 = 현재 라운드 (모두 마감이면 마지막 라운드)
  361. const rounds = challenge.value?.rounds || [];
  362. if (rounds.length) {
  363. const idx = rounds.findIndex((r) => !r.closed_at);
  364. activeRoundIdx.value = idx === -1 ? rounds.length - 1 : idx;
  365. }
  366. } catch (e) {
  367. console.error("[ChallengeDetail] 로드 실패:", e);
  368. challenge.value = null;
  369. } finally {
  370. isLoading.value = false;
  371. }
  372. };
  373. // 라운드 마감 — 확인 모달 → API
  374. const confirmCloseRound = (round) => {
  375. closingRound.value = round;
  376. showCloseRoundModal.value = true;
  377. };
  378. const handleCloseRound = async () => {
  379. showCloseRoundModal.value = false;
  380. const round = closingRound.value;
  381. closingRound.value = null;
  382. if (!round) return;
  383. errorMessage.value = "";
  384. try {
  385. const { data, error } = await post(`/challenge/round/${round.id}/close`, {});
  386. if (error || !data?.success) {
  387. errorMessage.value = error?.message || data?.message || "마감에 실패했습니다.";
  388. return;
  389. }
  390. successMessage.value = data.message || "라운드가 마감되었습니다.";
  391. await loadChallenge();
  392. } catch (e) {
  393. console.error("[ChallengeDetail] 마감 실패:", e);
  394. errorMessage.value = "서버 오류가 발생했습니다.";
  395. }
  396. };
  397. const confirmDelete = () => {
  398. showDeleteModal.value = true;
  399. };
  400. const handleDelete = async () => {
  401. showDeleteModal.value = false;
  402. errorMessage.value = "";
  403. try {
  404. const { data, error } = await del(`/challenge/${challengeId}`);
  405. if (error || !data?.success) {
  406. errorMessage.value = error?.message || data?.message || "삭제에 실패했습니다.";
  407. return;
  408. }
  409. successMessage.value = data.message || "챌린지가 삭제되었습니다.";
  410. setTimeout(() => router.push("/site-manager/challenge/list"), 800);
  411. } catch (e) {
  412. console.error("[ChallengeDetail] 삭제 실패:", e);
  413. errorMessage.value = "서버 오류가 발생했습니다.";
  414. }
  415. };
  416. const goToList = () => router.push("/site-manager/challenge/list");
  417. const goToEdit = () => router.push(`/site-manager/challenge/edit/${challengeId}`);
  418. onMounted(() => {
  419. loadChallenge();
  420. });
  421. </script>